{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "c48b753d",
   "metadata": {},
   "source": [
    "# Tutorial 06, case 6: Navier-Stokes problem with distributed control\n",
    "\n",
    "In this tutorial we solve the optimal control problem\n",
    "\n",
    "$$\\min J(y, u) = \\frac{1}{2} \\int_{\\Omega} |v - v_d|^2 dx + \\frac{\\alpha}{2} \\int_{\\Omega} |u|^2 dx$$\n",
    "s.t.\n",
    "$$\\begin{cases}\n",
    "- \\nu \\Delta v + v \\cdot \\nabla v + \\nabla p = f + u   & \\text{in } \\Omega\\\\\n",
    "                                \\text{div} v = 0       & \\text{in } \\Omega\\\\\n",
    "                                           v = 0       & \\text{on } \\partial\\Omega\n",
    "\\end{cases}$$\n",
    "\n",
    "where\n",
    "$$\\begin{align*}\n",
    "& \\Omega                      & \\text{unit square}\\\\\n",
    "& u \\in [L^2(\\Omega)]^2       & \\text{control variable}\\\\\n",
    "& v \\in [H^1_0(\\Omega)]^2     & \\text{state velocity variable}\\\\\n",
    "& p \\in L^2(\\Omega)           & \\text{state pressure variable}\\\\\n",
    "& \\alpha > 0                  & \\text{penalization parameter}\\\\\n",
    "& v_d                         & \\text{desired state}\\\\\n",
    "& \\nu                         & \\text{kinematic viscosity}\\\\\n",
    "& f                           & \\text{forcing term}\n",
    "\\end{align*}$$\n",
    "using an adjoint formulation solved by a one shot approach"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "8fb1f829",
   "metadata": {},
   "outputs": [],
   "source": [
    "import dolfinx.fem\n",
    "import dolfinx.fem.petsc\n",
    "import dolfinx.io\n",
    "import dolfinx.mesh\n",
    "import mpi4py.MPI\n",
    "import numpy as np\n",
    "import numpy.typing\n",
    "import petsc4py.PETSc\n",
    "import sympy\n",
    "import ufl\n",
    "import viskex"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "b72d80d9",
   "metadata": {},
   "outputs": [],
   "source": [
    "import multiphenicsx.fem\n",
    "import multiphenicsx.fem.petsc"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "38a9d2f8",
   "metadata": {},
   "source": [
    "### Mesh"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "a365feea",
   "metadata": {},
   "outputs": [],
   "source": [
    "mesh = dolfinx.mesh.create_unit_square(mpi4py.MPI.COMM_WORLD, 32, 32)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "80238caa-d6e6-4da0-a930-4aca3937831e",
   "metadata": {},
   "outputs": [],
   "source": [
    "# Create connectivities required by the rest of the code\n",
    "mesh.topology.create_connectivity(mesh.topology.dim - 1, mesh.topology.dim)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "850b6bff",
   "metadata": {},
   "outputs": [],
   "source": [
    "def bottom(x: np.typing.NDArray[np.float64]) -> np.typing.NDArray[np.bool_]:\n",
    "    \"\"\"Condition that defines the bottom boundary.\"\"\"\n",
    "    return abs(x[1] - 0.) < np.finfo(float).eps  # type: ignore[no-any-return]\n",
    "\n",
    "\n",
    "def left(x: np.typing.NDArray[np.float64]) -> np.typing.NDArray[np.bool_]:\n",
    "    \"\"\"Condition that defines the left boundary.\"\"\"\n",
    "    return abs(x[0] - 0.) < np.finfo(float).eps  # type: ignore[no-any-return]\n",
    "\n",
    "\n",
    "def top(x: np.typing.NDArray[np.float64]) -> np.typing.NDArray[np.bool_]:\n",
    "    \"\"\"Condition that defines the top boundary.\"\"\"\n",
    "    return abs(x[1] - 1.) < np.finfo(float).eps  # type: ignore[no-any-return]\n",
    "\n",
    "\n",
    "def right(x: np.typing.NDArray[np.float64]) -> np.typing.NDArray[np.bool_]:\n",
    "    \"\"\"Condition that defines the right boundary.\"\"\"\n",
    "    return abs(x[0] - 1.) < np.finfo(float).eps  # type: ignore[no-any-return]\n",
    "\n",
    "\n",
    "boundaries_entities = dict()\n",
    "boundaries_values = dict()\n",
    "for (boundary, boundary_id) in zip((bottom, left, top, right), (1, 2, 3, 4)):\n",
    "    boundaries_entities[boundary_id] = dolfinx.mesh.locate_entities_boundary(\n",
    "        mesh, mesh.topology.dim - 1, boundary)\n",
    "    boundaries_values[boundary_id] = np.full(\n",
    "        boundaries_entities[boundary_id].shape, boundary_id, dtype=np.int32)\n",
    "boundaries_entities_unsorted = np.hstack(list(boundaries_entities.values()))\n",
    "boundaries_values_unsorted = np.hstack(list(boundaries_values.values()))\n",
    "boundaries_entities_argsort = np.argsort(boundaries_entities_unsorted)\n",
    "boundaries_entities_sorted = boundaries_entities_unsorted[boundaries_entities_argsort]\n",
    "boundaries_values_sorted = boundaries_values_unsorted[boundaries_entities_argsort]\n",
    "boundaries = dolfinx.mesh.meshtags(\n",
    "    mesh, mesh.topology.dim - 1,\n",
    "    boundaries_entities_sorted, boundaries_values_sorted)\n",
    "boundaries.name = \"boundaries\""
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "60141d1a",
   "metadata": {},
   "outputs": [],
   "source": [
    "boundaries_1234 = boundaries.indices[np.isin(boundaries.values, (1, 2, 3, 4))]"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "45f51cd3",
   "metadata": {},
   "outputs": [],
   "source": [
    "viskex.dolfinx.plot_mesh(mesh)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "bfa1a807",
   "metadata": {},
   "outputs": [],
   "source": [
    "viskex.dolfinx.plot_mesh_tags(mesh, boundaries, \"boundaries\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "859eddd9",
   "metadata": {},
   "source": [
    "### Function spaces"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "d8bc566c",
   "metadata": {},
   "outputs": [],
   "source": [
    "Y_velocity = dolfinx.fem.functionspace(mesh, (\"Lagrange\", 2, (mesh.geometry.dim, )))\n",
    "Y_pressure = dolfinx.fem.functionspace(mesh, (\"Lagrange\", 1))\n",
    "U = dolfinx.fem.functionspace(mesh, (\"Lagrange\", 2, (mesh.geometry.dim, )))\n",
    "Q_velocity = Y_velocity.clone()\n",
    "Q_pressure = Y_pressure.clone()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "089a93b8",
   "metadata": {},
   "source": [
    "### Trial and test functions"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "08bf0c85",
   "metadata": {},
   "outputs": [],
   "source": [
    "(dv, dp) = (ufl.TrialFunction(Y_velocity), ufl.TrialFunction(Y_pressure))\n",
    "(w, q) = (ufl.TestFunction(Y_velocity), ufl.TestFunction(Y_pressure))\n",
    "du = ufl.TrialFunction(U)\n",
    "r = ufl.TestFunction(U)\n",
    "(dz, db) = (ufl.TrialFunction(Q_velocity), ufl.TrialFunction(Q_pressure))\n",
    "(s, d) = (ufl.TestFunction(Q_velocity), ufl.TestFunction(Q_pressure))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "53cd5081",
   "metadata": {},
   "source": [
    "### Solution"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "1a10dcfb",
   "metadata": {},
   "outputs": [],
   "source": [
    "(v, p) = (dolfinx.fem.Function(Y_velocity), dolfinx.fem.Function(Y_pressure))\n",
    "u = dolfinx.fem.Function(U)\n",
    "(z, b) = (dolfinx.fem.Function(Q_velocity), dolfinx.fem.Function(Q_pressure))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2fb105bd",
   "metadata": {},
   "source": [
    " ### Problem data"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "4ecb171d",
   "metadata": {},
   "outputs": [],
   "source": [
    "alpha = 1.e-5\n",
    "epsilon = 1.e-5\n",
    "x, y = sympy.symbols(\"x[0], x[1]\")\n",
    "psi_d = 10 * (1 - sympy.cos(0.8 * np.pi * x)) * (1 - sympy.cos(0.8 * np.pi * y)) * (1 - x)**2 * (1 - y)**2\n",
    "v_d_x = sympy.lambdify([x, y], psi_d.diff(y, 1))\n",
    "v_d_y = sympy.lambdify([x, y], - psi_d.diff(x, 1))\n",
    "v_d = dolfinx.fem.Function(Y_velocity)\n",
    "v_d.interpolate(lambda x: np.stack((v_d_x(x[0], x[1]), v_d_y(x[0], x[1])), axis=0))\n",
    "nu = 0.01\n",
    "ff = dolfinx.fem.Constant(mesh, tuple(petsc4py.PETSc.ScalarType(0) for _ in range(2)))\n",
    "bc0 = np.zeros((2, ), dtype=petsc4py.PETSc.ScalarType)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "47ee6ebf",
   "metadata": {},
   "source": [
    "### Optimality conditions"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "fce12748",
   "metadata": {},
   "outputs": [],
   "source": [
    "F = [nu * ufl.inner(ufl.grad(z), ufl.grad(w)) * ufl.dx\n",
    "     + ufl.inner(z, ufl.grad(w) * v) * ufl.dx + ufl.inner(z, ufl.grad(v) * w) * ufl.dx\n",
    "     - ufl.inner(b, ufl.div(w)) * ufl.dx + ufl.inner(v - v_d, w) * ufl.dx,\n",
    "     - ufl.inner(ufl.div(z), q) * ufl.dx + epsilon * ufl.inner(b, q) * ufl.dx,\n",
    "     alpha * ufl.inner(u, r) * ufl.dx - ufl.inner(z, r) * ufl.dx,\n",
    "     nu * ufl.inner(ufl.grad(v), ufl.grad(s)) * ufl.dx + ufl.inner(ufl.grad(v) * v, s) * ufl.dx\n",
    "     - ufl.inner(p, ufl.div(s)) * ufl.dx - ufl.inner(u + ff, s) * ufl.dx,\n",
    "     - ufl.inner(ufl.div(v), d) * ufl.dx + epsilon * ufl.inner(p, d) * ufl.dx]\n",
    "dF = [[ufl.derivative(F_i, u_j, du_j) for (u_j, du_j) in zip((v, p, u, z, b), (dv, dp, du, dz, db))] for F_i in F]\n",
    "dF[3][3] = dolfinx.fem.Constant(mesh, petsc4py.PETSc.ScalarType(0)) * ufl.inner(dz, s) * ufl.dx\n",
    "bdofs_Y_velocity_1234 = dolfinx.fem.locate_dofs_topological(\n",
    "    Y_velocity, mesh.topology.dim - 1, boundaries_1234)\n",
    "bdofs_Q_velocity_1234 = dolfinx.fem.locate_dofs_topological(\n",
    "    Q_velocity, mesh.topology.dim - 1, boundaries_1234)\n",
    "bc = [dolfinx.fem.dirichletbc(bc0, bdofs_Y_velocity_1234, Y_velocity),\n",
    "      dolfinx.fem.dirichletbc(bc0, bdofs_Q_velocity_1234, Q_velocity)]"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c638350c",
   "metadata": {},
   "source": [
    "### Cost functional"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "5b8ca15f",
   "metadata": {},
   "outputs": [],
   "source": [
    "J = 0.5 * ufl.inner(v - v_d, v - v_d) * ufl.dx + 0.5 * alpha * ufl.inner(u, u) * ufl.dx\n",
    "J_cpp = dolfinx.fem.form(J)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "f85e098d",
   "metadata": {},
   "source": [
    "### Class for interfacing with SNES"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "1c4a448e",
   "metadata": {},
   "outputs": [],
   "source": [
    "class NonlinearBlockProblem:\n",
    "    \"\"\"Define a nonlinear problem, interfacing with SNES.\"\"\"\n",
    "\n",
    "    def __init__(  # type: ignore[no-any-unimported]\n",
    "        self, F: list[ufl.Form], dF: list[list[ufl.Form]],\n",
    "        solutions: tuple[dolfinx.fem.Function, ...], bcs: list[dolfinx.fem.DirichletBC]\n",
    "    ) -> None:\n",
    "        self._F = dolfinx.fem.form(F)\n",
    "        self._dF = dolfinx.fem.form(dF)\n",
    "        self._obj_vec = dolfinx.fem.petsc.create_vector_block(self._F)\n",
    "        self._solutions = solutions\n",
    "        self._bcs = bcs\n",
    "\n",
    "    def create_snes_solution(self) -> petsc4py.PETSc.Vec:  # type: ignore[no-any-unimported]\n",
    "        \"\"\"\n",
    "        Create a petsc4py.PETSc.Vec to be passed to petsc4py.PETSc.SNES.solve.\n",
    "\n",
    "        The returned vector will be initialized with the initial guesses provided in `self._solutions`,\n",
    "        properly stacked together in a single block vector.\n",
    "        \"\"\"\n",
    "        x = dolfinx.fem.petsc.create_vector_block(self._F)\n",
    "        with multiphenicsx.fem.petsc.BlockVecSubVectorWrapper(\n",
    "                x, [c.function_space.dofmap for c in self._solutions]) as x_wrapper:\n",
    "            for x_wrapper_local, component in zip(x_wrapper, self._solutions):\n",
    "                with component.x.petsc_vec.localForm() as component_local:\n",
    "                    x_wrapper_local[:] = component_local\n",
    "        return x\n",
    "\n",
    "    def update_solutions(self, x: petsc4py.PETSc.Vec) -> None:  # type: ignore[no-any-unimported]\n",
    "        \"\"\"Update `self._solutions` with data in `x`.\"\"\"\n",
    "        x.ghostUpdate(addv=petsc4py.PETSc.InsertMode.INSERT, mode=petsc4py.PETSc.ScatterMode.FORWARD)\n",
    "        with multiphenicsx.fem.petsc.BlockVecSubVectorWrapper(\n",
    "                x, [c.function_space.dofmap for c in self._solutions]) as x_wrapper:\n",
    "            for x_wrapper_local, component in zip(x_wrapper, self._solutions):\n",
    "                with component.x.petsc_vec.localForm() as component_local:\n",
    "                    component_local[:] = x_wrapper_local\n",
    "\n",
    "    def obj(  # type: ignore[no-any-unimported]\n",
    "        self, snes: petsc4py.PETSc.SNES, x: petsc4py.PETSc.Vec\n",
    "    ) -> np.float64:\n",
    "        \"\"\"Compute the norm of the residual.\"\"\"\n",
    "        self.F(snes, x, self._obj_vec)\n",
    "        return self._obj_vec.norm()  # type: ignore[no-any-return]\n",
    "\n",
    "    def F(  # type: ignore[no-any-unimported]\n",
    "        self, snes: petsc4py.PETSc.SNES, x: petsc4py.PETSc.Vec, F_vec: petsc4py.PETSc.Vec\n",
    "    ) -> None:\n",
    "        \"\"\"Assemble the residual.\"\"\"\n",
    "        self.update_solutions(x)\n",
    "        with F_vec.localForm() as F_vec_local:\n",
    "            F_vec_local.set(0.0)\n",
    "        dolfinx.fem.petsc.assemble_vector_block(  # type: ignore[misc]\n",
    "            F_vec, self._F, self._dF, self._bcs, x0=x, alpha=-1.0)\n",
    "\n",
    "    def dF(  # type: ignore[no-any-unimported]\n",
    "        self, snes: petsc4py.PETSc.SNES, x: petsc4py.PETSc.Vec, dF_mat: petsc4py.PETSc.Mat,\n",
    "        _: petsc4py.PETSc.Mat\n",
    "    ) -> None:\n",
    "        \"\"\"Assemble the jacobian.\"\"\"\n",
    "        dF_mat.zeroEntries()\n",
    "        dolfinx.fem.petsc.assemble_matrix_block(  # type: ignore[misc]\n",
    "            dF_mat, self._dF, self._bcs, diagonal=1.0)  # type: ignore[arg-type]\n",
    "        dF_mat.assemble()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "fc3730cc",
   "metadata": {},
   "source": [
    "### Uncontrolled functional value"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "aa59263f",
   "metadata": {},
   "outputs": [],
   "source": [
    "# Create problem by extracting state forms from the optimality conditions\n",
    "F_state = [ufl.replace(F[i], {s: w, d: q}) for i in (3, 4)]\n",
    "dF_state = [[ufl.replace(dF[i][j], {s: w, d: q}) for j in (0, 1)] for i in (3, 4)]\n",
    "bc_state = [bc[0]]\n",
    "problem_state = NonlinearBlockProblem(F_state, dF_state, (v, p), bc_state)\n",
    "F_vec_state = dolfinx.fem.petsc.create_vector_block(problem_state._F)\n",
    "dF_mat_state = dolfinx.fem.petsc.create_matrix_block(problem_state._dF)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "90aab3f2",
   "metadata": {},
   "outputs": [],
   "source": [
    "# Solve\n",
    "snes = petsc4py.PETSc.SNES().create(mesh.comm)\n",
    "snes.setTolerances(max_it=20)\n",
    "snes.getKSP().setType(\"preonly\")\n",
    "snes.getKSP().getPC().setType(\"lu\")\n",
    "snes.getKSP().getPC().setFactorSolverType(\"mumps\")\n",
    "snes.setObjective(problem_state.obj)\n",
    "snes.setFunction(problem_state.F, F_vec_state)\n",
    "snes.setJacobian(problem_state.dF, J=dF_mat_state, P=None)\n",
    "snes.setMonitor(lambda _, it, residual: print(it, residual))\n",
    "vp = problem_state.create_snes_solution()\n",
    "snes.solve(None, vp)\n",
    "problem_state.update_solutions(vp)  # TODO can this be safely removed?\n",
    "vp.destroy()\n",
    "snes.destroy()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "82e1f778",
   "metadata": {},
   "outputs": [],
   "source": [
    "J_uncontrolled = mesh.comm.allreduce(dolfinx.fem.assemble_scalar(J_cpp), op=mpi4py.MPI.SUM)\n",
    "print(\"Uncontrolled J =\", J_uncontrolled)\n",
    "assert np.isclose(J_uncontrolled, 0.1784536)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "dd330dba",
   "metadata": {},
   "outputs": [],
   "source": [
    "viskex.dolfinx.plot_vector_field(v, \"uncontrolled state velocity\", glyph_factor=1e-1)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "0c46dccc",
   "metadata": {},
   "outputs": [],
   "source": [
    "viskex.dolfinx.plot_scalar_field(p, \"uncontrolled state pressure\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "83f67bb0",
   "metadata": {},
   "source": [
    "### Optimal control"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c2d4169c",
   "metadata": {},
   "outputs": [],
   "source": [
    "# PYTEST_XFAIL_AND_SKIP_NEXT: ufl.derivative(adjoint of the trilinear term) introduces spurious conj(trial)\n",
    "assert not np.issubdtype(petsc4py.PETSc.ScalarType, np.complexfloating)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "08d7013f",
   "metadata": {},
   "outputs": [],
   "source": [
    "# Create problem associated to the optimality conditions\n",
    "problem = NonlinearBlockProblem(F, dF, (v, p, u, z, b), bc)\n",
    "F_vec = dolfinx.fem.petsc.create_vector_block(problem._F)\n",
    "dF_mat = dolfinx.fem.petsc.create_matrix_block(problem._dF)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "a05b21b8",
   "metadata": {},
   "outputs": [],
   "source": [
    "# Solve\n",
    "snes = petsc4py.PETSc.SNES().create(mesh.comm)\n",
    "snes.setTolerances(max_it=20)\n",
    "snes.getKSP().setType(\"preonly\")\n",
    "snes.getKSP().getPC().setType(\"lu\")\n",
    "snes.getKSP().getPC().setFactorSolverType(\"mumps\")\n",
    "snes.setObjective(problem.obj)\n",
    "snes.setFunction(problem.F, F_vec)\n",
    "snes.setJacobian(problem.dF, J=dF_mat, P=None)\n",
    "snes.setMonitor(lambda _, it, residual: print(it, residual))\n",
    "vpuzb = problem.create_snes_solution()\n",
    "snes.solve(None, vpuzb)\n",
    "problem.update_solutions(vpuzb)  # TODO can this be safely removed?\n",
    "vpuzb.destroy()\n",
    "snes.destroy()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "4a0ddfb5",
   "metadata": {},
   "outputs": [],
   "source": [
    "J_controlled = mesh.comm.allreduce(dolfinx.fem.assemble_scalar(J_cpp), op=mpi4py.MPI.SUM)\n",
    "print(\"Optimal J =\", J_controlled)\n",
    "assert np.isclose(J_controlled, 9.9127635e-7)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "1a15dddd",
   "metadata": {},
   "outputs": [],
   "source": [
    "viskex.dolfinx.plot_vector_field(v, \"state velocity\", glyph_factor=1e-1)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "fe8d5091",
   "metadata": {},
   "outputs": [],
   "source": [
    "viskex.dolfinx.plot_scalar_field(p, \"state pressure\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "df9c3d73",
   "metadata": {},
   "outputs": [],
   "source": [
    "viskex.dolfinx.plot_vector_field(u, \"control\", glyph_factor=1e-1)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "9aea93b7",
   "metadata": {},
   "outputs": [],
   "source": [
    "viskex.dolfinx.plot_vector_field(z, \"adjoint velocity\", glyph_factor=1e4)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "68979bb7",
   "metadata": {},
   "outputs": [],
   "source": [
    "viskex.dolfinx.plot_scalar_field(b, \"adjoint pressure\")"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3 (ipykernel)",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython"
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
